本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。
購書連結 https://www.tenlong.com.tw/products/9789864344130
讓我們再次重新認識 JavaScript!
在上一篇文章中,我們介紹了基本型別包裹器 (Primitive Wrapper),也理解到基本型別之所以會有「方法」以及「屬性」,都是因為在存取它們的時候,JavaScript 會透過 Wrapper 自動將它們暫時轉型為「物件」的型態。
而這些物件的「方法」以及「屬性」又是從哪裡來的呢?
這就要從源頭的「原型」(Prototype) 講起。
前幾天的文章中曾經提到過,JavaScript 是一門物件導向的程式語言,因為沒有 Class,所以它的繼承方法是透過 「原型」(prototype) 來進行實作。
那麼「原型」繼承的概念是什麼呢? 簡單來說,透過「原型」繼承可以讓本來沒有某個屬性的物件去存取其他物件的屬性。
肥宅如我就拿電玩人物當例子吧,不知道大家有沒有玩過洛克人。 洛克人有趣的地方就是打敗了某關卡的頭目之後,就可以擁有那個敵人的武器。
// 洛克人的武器是 buster 飛彈
var rockman = { buster: true };
// 剪刀人的武器是剪刀
var cutman = { cutter: true };
像上面這樣,建立了兩個物件,分別代表洛克人與剪刀人。
然後,我們可以透過 in
來判斷某個屬性是否可以透過這個物件來存取:
// 注意,屬性名稱必須是「字串」
console.log( 'buster' in rockman ); // true
console.log( 'cutter' in rockman ); // false
很顯然,洛克人目前只有飛彈,並沒有獲得剪刀人的武器 cutter
。
那麼,洛克人辛辛苦苦把剪刀人幹掉了之後,這時候就可以取得他的武器。 以 JavaScript 來說,我們就可以透過 Object.setPrototypeOf()
將「剪刀人指定為原型」。
在 JavaScript 裡,物件原型是物件的內部屬性,而且無法直接存取 (所以通常會直接被標示為 [[prototype]]
),但我們可以透過 Object.setPrototypeOf()
來指定物件之間的原型關係。
Object.setPrototypeOf(rockman, cutman);
像這樣,第一個參數是「繼承者」的物件,第二個則是被當作「原型」的物件。
如果以洛克人的範例來說,可以這樣寫:
// 洛克人的武器是 buster 飛彈
var rockman = { buster: true };
// 剪刀人的武器是剪刀
var cutman = { cutter: true };
console.log( 'buster' in rockman ); // true
console.log( 'cutter' in rockman ); // false
// 指定 cutman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, cutman);
console.log( 'buster' in rockman ); // true
// 透過原型繼承,現在洛克人也可以使用剪刀人的武器了
console.log( 'cutter' in rockman ); // true
不過可惜的是,在原型繼承的規則裡,同一個物件無法指定兩種原型物件。
也就是說,假設我們再新增一個「氣力人」:
// 氣力人的武器是超級手臂
var gutsman = { superArm: true };
// 指定 gutsman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, gutsman);
// 這個時候洛克人也可以使用氣力人的超級手臂
console.log( 'superArm' in rockman ); // true
// 但是剪刀卻不見了,哭哭
console.log( 'cutter' in rockman ); // false
如果我們希望洛克人可以同時使用「剪刀」與「超級手臂」,要怎麼做呢?
幸好在原型繼承之中,有個觀念叫「原型鏈」(Prototype Chain)。
當我們從某個物件要試著去存取「不存在」的屬性時,那麼 JavaScript 就會往它的 [[prototype]]
原型物件去尋找。
所以說,既然洛克人只能繼承剪刀人的武器,那麼我可不可以順勢讓剪刀人去繼承氣力人的超級手臂呢?
// 洛克人的武器是 buster 飛彈
var rockman = { buster: true };
// 剪刀人的武器是剪刀
var cutman = { cutter: true };
// 氣力人的武器是超級手臂
var gutsman = { superArm: true };
// 指定 cutman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, cutman);
// 指定 gutsman 為 cutman 的「原型」
Object.setPrototypeOf(cutman, gutsman);
// 這樣洛克人就可以順著「原型鏈」取得各種武器了!
console.log( 'buster' in rockman ); // true
console.log( 'cutter' in rockman ); // true
console.log( 'superArm' in rockman ); // true
謎之聲:有了超級手臂的剪刀人跟遊戲的設定好像不太一樣啊?
重點是原型鏈,這種小事就不要太在意啦。
如同上面所說,當我們嘗試在某個物件存取一個不存在該物件的屬性時,它會繼續往它的「原型物件」[[prototype]]
去尋找,那麼這個 [[prototype]]
究竟會找到什麼時候才停止呢?
事實上,在 JavaScript 幾乎所有的物件 (環境宿主物件除外) 順著原型鏈找到最頂層級時,都會找到 Object.prototype
才停止,因為 Object.prototype
是 JavaScript 所有物件的起源。
換言之,在 Object.prototype
提供的所有方法,在 JavaScript 的所有物件的可以呼叫它,像是我們曾介紹過的這些方法:
Object.prototype.hasOwnProperty()
Object.prototype.toString()
Object.prototype.valueOf()
即便建立物件時,沒有定義這些方法,但基於原型鏈的繼承,我們還是可以呼叫這些方法。
複習一下,先前我們介紹過「函式」與「建構式」,這裡透過範例說明:
var Person = function(){};
// 函式也是物件,所以可以透過 prototype 來擴充每一個透過這個函式所建構的物件
Person.prototype.sayHello = function(){
return "Hi!";
}
var p1 = Person();
var p2 = new Person();
變數 p1
的內容,是直接呼叫 Person
的結果,但因為這個函式什麼都沒有回傳,所以結果是 undefined
。
變數 p2
的內容則是透過 new
關鍵字來建立一個物件。 但由於函式也是物件,所以可以透過 prototype
來擴充每一個透過這個函式所建構的物件。
所以當我們透過 new Person()
建立了新物件並指定給 p2
後,p2
就可以透過原型取得呼叫 sayHello()
的能力,即使我們尚未對 p2
定義這個方法。
p2.sayHello(); // "Hi!"
簡單來說,就是當函式被建立的時候,都會有個原型物件,當我們將這個函式當作建構式來建立新的物件時,這個函式的原型物件,就會被當作這個新物件的原型。
那麼有趣的事來了,如果我們在建構式中建立一個「同名」的實例方法:
var Person = function(){
this.sayHello = function(){
return "Yo!";
};
};
Person.prototype.sayHello = function(){
return "Hi!";
}
var p = new Person();
請問,執行 p.sayHello()
的結果會是什麼?
答案是 "Yo!"
。
也就是說,當物件實體與它的原型同時擁有同樣的屬性或方法時,會優先存取自己的屬性或方法,如果沒有才會再順著原型鏈向上尋找。
而關於從原型繼承屬性或方法,我們可以簡單歸納出幾種狀況:
如果物件實體與它的原型同時擁有同樣的屬性或方法時,會優先存取自己的屬性或方法。
如果物件實體找不到某個屬性或方法時,會往它的原型物件尋找。
如果在原型物件或更上層的原型物件有發現這個屬性,且屬性描述的 writable
為 true
,則會為這物件實體新增對應的屬性或方法。 [註1]
同上,但若該屬性描述的 writable
為 false
,那麼就等於目標物件會多出一個「唯讀」的屬性,且事後無法再新增或修改。
如果在原型物件或更上層的原型物件有發現這個屬性,但這個屬性其實是一個「設值器」(setter function),那麼呼叫的永遠會是那個設值器,目標物件的屬性也無法被重新定義。
那麼,我們要怎麼知道某個屬性是透過「原型」繼承來的,還是物件本身所有的呢?
回到一開始的洛克人範例:
// 洛克人的武器是 buster 飛彈
var rockman = { buster: true, name: 'rock' };
// 剪刀人的武器是剪刀
var cutman = { cutter: true };
// 氣力人的武器是超級手臂
var gutsman = { superArm: true };
// 指定 cutman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, cutman);
// 指定 gutsman 為 cutman 的「原型」
Object.setPrototypeOf(cutman, gutsman);
如果你希望透過「原型鏈」檢查屬性,可以用 in
:
console.log( 'buster' in rockman ); // true
console.log( 'cutter' in rockman ); // true
console.log( 'superArm' in rockman ); // true
如果你希望檢查的屬性,是否為「物件本身」所有,則可以透過 hasOwnProperty()
:
console.log( rockman.hasOwnProperty('buster') ); // true
console.log( rockman.hasOwnProperty('superArm') ); // false
[註1]: 對於屬性描述器不熟悉的朋友,可以往前查閱 重新認識 JavaScript: Day 22 深入理解 JavaScript 物件屬性 一文。